Jerry's Log

Fenwick Tree

contents

펜윅 트리(Fenwick Tree), 또는 이진 인덱스 트리(Binary Indexed Tree, BIT) 라고도 널리 알려진 자료구조에 대해 알아보겠습니다.

이 트리는 매우 우아하고 메모리 효율적이며, 동적 누적 합(Dynamic Prefix Sums) 이라는 단 하나의 특정 문제를 완벽하게 해결하기 위해 고안되었습니다.


1. 해결하려는 문제

$N$개의 숫자로 이루어진 배열이 있다고 상상해 보세요. 다음 두 가지 연산을 반복적으로 수행해야 합니다:

  1. 업데이트 (Update): 특정 인덱스의 값을 변경하거나 더합니다.
  2. 쿼리 (Query): 인덱스 $1$부터 $i$까지의 모든 요소의 합(누적 합)을 구합니다.

기존의 일반적인 방법들이 이 두 연산의 균형을 맞추는 데 어떻게 실패하는지 살펴봅시다.

접근 방식 업데이트 시간 누적 합 쿼리 시간 설명
일반 배열 (Standard Array) $O(1)$ $O(N)$ 값 변경은 즉시 가능하지만, 합을 구하려면 배열을 루프 돌아야 합니다.
누적 합 배열 (Prefix Sum Array) $O(N)$ $O(1)$ 합은 즉시 알 수 있지만, 값 하나를 바꾸면 그 뒤의 모든 누적 합을 다시 계산해야 합니다.
펜윅 트리 (BIT) $O(\log N)$ $O(\log N)$ 비트 연산을 활용하여 완벽한 로그 시간(Logarithmic) 균형을 맞춥니다.

2. 핵심 직관: 2의 거듭제곱

펜윅 트리는 근본적인 수학적 사실에 기반합니다: 모든 정수는 2의 거듭제곱들의 합으로 표현할 수 있다 (이것이 바로 이진법의 원리입니다).

예를 들어, 숫자 $13$은 이진수로 1101입니다.

$13 = 8 + 4 + 1$

인덱스 $1$부터 $13$까지의 누적 합을 구하고 싶을 때, 펜윅 트리는 13개의 숫자를 일일이 더하지 않습니다. 대신 범위를 정확히 $3$개의 미리 계산된 덩어리(Chunk)로 나눕니다:

이렇게 하면, 어떤 수 $N$에 대한 쿼리든 최대 $O(\log N)$ 번의 덧셈만으로 해결할 수 있습니다.


3. 마법의 공식: 최하위 비트 (LSB) 추출

트리를 이리저리 건너뛰기 위해서는 인덱스의 가장 마지막에 켜진 비트(Last Set Bit, LSB) 를 추출해야 합니다. LSB는 숫자의 이진수 표현에서 가장 오른쪽에 있는 1을 의미합니다.

비트 연산 트릭:

대부분의 프로그래밍 언어에서 음수는 '2의 보수(Two's Complement)'로 표현됩니다(모든 비트를 반전시킨 후 1을 더함). 이러한 수학적 특성 덕분에, 다음과 같은 마법 같은 $O(1)$ 연산으로 LSB를 분리해낼 수 있습니다:

$$LSB(i) = i \ & \ (-i)$$


4. 배열은 실제로 무엇을 저장하는가?

펜윅 트리는 단순한 1차원 배열로 구현되며, 일반적으로 1-based index (1부터 시작하는 인덱스) 를 사용합니다.

tree[i]를 인덱스 $i$에 저장된 값이라고 합시다.

tree[i]는 특정 구간에 속한 요소들의 합을 저장합니다. 이 구간의 길이는 정확히 $LSB(i)$와 같습니다.

구체적으로, tree[i]는 원본 배열의 다음 범위의 합을 저장합니다:

$$[i - LSB(i) + 1, \quad i]$$


5. 두 가지 주요 연산

A. 쿼리 (인덱스 $i$까지의 누적 합)

$1$부터 $i$까지의 합을 구하려면, tree[i]의 값을 더한 뒤 $i$에서 LSB를 뺍니다. 이 과정을 $i$가 $0$이 될 때까지 반복합니다.

예시: Query(13)

  1. $13$은 1101. tree[13]을 더합니다. LSB($1$)를 뺍니다. 다음 인덱스는 $12$.
  2. $12$는 1100. tree[12]를 더합니다. LSB($4$)를 뺍니다. 다음 인덱스는 $8$.
  3. $8$은 1000. tree[8]을 더합니다. LSB($8$)를 뺍니다. 다음 인덱스는 $0$. 끝!

이동 논리: 트리 아래로(왼쪽으로) 이동.

i = i - (i & -i)

B. 업데이트 (인덱스 $i$에 delta 더하기)

원본 배열의 $i$번째 값을 변경하면, tree[i]뿐만 아니라 인덱스 $i$를 포함하고 있는 트리의 모든 더 큰 구간들도 업데이트해야 합니다.

현재 구간을 포함하는 다음으로 큰 구간을 찾으려면 $i$에 LSB를 더합니다.

예시: Update(5, delta)

  1. $5$는 0101. tree[5]delta를 더합니다. LSB($1$)를 더합니다. 다음 인덱스는 $6$.
  2. $6$은 0110. tree[6]delta를 더합니다. LSB($2$)를 더합니다. 다음 인덱스는 $8$.
  3. $8$은 1000. tree[8]delta를 더합니다. LSB($8$)를 더합니다. 다음 인덱스는 $16$. (배열 크기를 넘어가면 멈춤).

이동 논리: 트리 위로(오른쪽으로) 이동.

i = i + (i & -i)


6. 구현 코드 (Java)

깔끔하고 표준적인 펜윅 트리 구현체입니다.

매우 중요한 주의사항: 펜윅 트리는 반드시 1부터 시작하는 인덱스(1-indexed) 를 사용해야 합니다. 만약 인덱스 $0$을 전달하면, 0 & -0 = 0이 되어 업데이트 루프인 i += 0이 무한 루프에 빠지게 됩니다!

public class FenwickTree {
    private int[] tree;
    private int n;

    // 특정 크기로 트리 초기화
    public FenwickTree(int size) {
        // 1-based 인덱싱을 사용하므로 size + 1 크기로 할당합니다.
        this.n = size;
        this.tree = new int[n + 1]; 
    }

    // 인덱스 'i'의 요소에 'delta'를 더함 (1-based index)
    public void add(int i, int delta) {
        if (i <= 0) throw new IllegalArgumentException("인덱스는 0보다 커야 합니다.");
        
        // i를 포함하는 모든 큰 구간을 업데이트하기 위해 트리 위로 이동
        while (i <= n) {
            tree[i] += delta;
            i += (i & -i); // LSB 더하기
        }
    }

    // 1부터 i까지의 누적 합 구하기 (1-based index)
    public int query(int i) {
        int sum = 0;
        
        // 미리 계산된 덩어리들을 누적하며 트리 아래로 이동
        while (i > 0) {
            sum += tree[i];
            i -= (i & -i); // LSB 빼기
        }
        return sum;
    }

    // 특정 구간 [left, right]의 합 구하기
    public int rangeQuery(int left, int right) {
        return query(right) - query(left - 1);
    }
}

7. 복잡도 분석

지표 복잡도 설명
시간 (업데이트) $O(\log N)$ 최악의 경우, 켜진 비트의 수(루프 반복 횟수)는 $\log N$에 비례합니다.
시간 (쿼리) $O(\log N)$ 위와 동일합니다. LSB를 최대 $\log N$번 뺍니다.
공간 복잡도 $O(N)$ 데이터와 정확히 같은 크기의 배열(1-based 처리를 위한 +1 포함) 1개만 사용합니다. 포인터나 노드 객체가 필요 없습니다.

요약 비교: 펜윅 트리 vs 세그먼트 트리

세그먼트 트리(Segment Tree) 가 있는데 굳이 왜 이걸 쓰는지 궁금하실 수 있습니다.


구간 업데이트

기본적으로 제공되는 표준 펜윅 트리는 점 업데이트와 구간 쿼리(Point Update and Range Query, PURQ) 만을 처리합니다. 이를 확장하여 구간 업데이트(Range Updates) 를 처리하려면, 트리가 실제로 저장하는 내용물 자체를 바꿔야 합니다.

이 업그레이드에는 두 가지 단계가 있습니다:

  1. RUPQ: 구간 업데이트 + 점 쿼리 (상대적으로 쉬움)
  2. RURQ: 구간 업데이트 + 구간 쿼리 (고급, 두 개의 트리가 필요함)

하나씩 차근차근 살펴보겠습니다.


1단계: 구간 업데이트 + 점 쿼리 (RUPQ)

$[L, R]$ 구간에 있는 모든 요소에 값 $V$를 더하고, 나중에 "$i$번째 인덱스의 실제 값이 무엇인가?"라고 묻고 싶다고 가정해 봅시다.

핵심 트릭: 차이 배열 (Difference Array)

펜윅 트리에 실제 배열의 값을 저장하는 대신, 인접한 두 요소 간의 차이를 저장합니다: $D[i] = A[i] - A[i-1]$.

이 차이 배열에서 인덱스 $i$까지 누적 합(Prefix sum) 을 구하면, 놀랍게도 실제 $A[i]$의 값을 얻을 수 있습니다!

$$A[i] = D[1] + D[2] + \dots + D[i]$$

업데이트 방법:

구간 $[L, R]$ 전체에 $V$를 더하려면, 차이 배열에서는 단 두 지점만 변경하면 됩니다.

  1. 인덱스 $L$에 $V$를 더합니다: 이렇게 하면 $A[L]$과 그 이후의 모든 요소가 계산될 때 $V$만큼 증가합니다.
  2. 인덱스 $R + 1$에서 $V$를 뺍니다: 구간이 끝난 직후부터는 다시 원래 값으로 돌아오도록 상쇄시키는 역할을 합니다.

코드 로직 (RUPQ):

// 구간 [L, R]에 V를 더하기 위해
public void updateRange(int L, int R, int V) {
    add(L, V);         // L부터 끝까지 V를 더하는 효과
    add(R + 1, -V);    // R+1부터 끝까지 V를 빼서 상쇄하는 효과
}

// 인덱스 i의 실제 값을 구하기 위해
public int queryPoint(int i) {
    return query(i); // 표준 BIT의 누적 합 로직 그대로 사용
}

2단계: 구간 업데이트 + 구간 쿼리 (RURQ)

이것이 최종 형태입니다. 구간 $[L, R]$에 $V$를 더하고, 나중에 특정 구간 $[X, Y]$의 총합을 묻고 싶을 때 사용합니다.

1단계에서 배운 차이 배열 $D$를 사용할 때, 실제 배열 $A$의 첫 번째 요소부터 인덱스 $x$까지의 누적 합은 수학적으로 어떤 모습이 될까요?

$A[1]$부터 $A[x]$까지의 합을 나열해 봅시다:

$$\sum_{i=1}^{x} A[i] = A[1] + A[2] + \dots + A[x]$$

여기에 $A[i]$ 대신 차이 배열 요소들($D[1] + \dots + D[i]$)을 대입해 봅니다:

$$\sum_{i=1}^{x} A[i] = D[1] + (D[1]+D[2]) + (D[1]+D[2]+D[3]) + \dots$$

$D$ 항들을 기준으로 묶어보면, $D[1]$은 $x$번, $D[2]$는 $(x-1)$번, $D[i]$는 $(x - i + 1)$번 나타나는 것을 알 수 있습니다:

$$\sum_{i=1}^{x} A[i] = \sum_{i=1}^{x} D[i] \times (x - i + 1)$$

이 식을 펜윅 트리로 계산하기 쉽게 두 개의 독립적인 시그마(합)로 쪼갤 수 있습니다:

$$\sum_{i=1}^{x} A[i] = (x + 1) \sum_{i=1}^{x} D[i] \quad - \quad \sum_{i=1}^{x} (D[i] \times i)$$

마법 같은 구현 방법:

이 식을 계산하려면 두 개의 표준 펜윅 트리만 있으면 됩니다!

구간 $[L, R]$에 값 $V$를 업데이트할 때마다 두 트리를 동시에 업데이트해 주면 됩니다.


RURQ 구현 코드 (Java)

두 개의 기본 펜윅 트리를 내부적으로 사용하는 깔끔하고 완벽한 구현체입니다.

public class FenwickTreeRURQ {
    private int n;
    private long[] bit1; // D[i]를 관리
    private long[] bit2; // D[i] * i를 관리

    public FenwickTreeRURQ(int size) {
        this.n = size;
        bit1 = new long[n + 1];
        bit2 = new long[n + 1];
    }

    // 표준 BIT 점 업데이트 헬퍼 메서드
    private void add(long[] bit, int i, long delta) {
        while (i <= n) {
            bit[i] += delta;
            i += (i & -i);
        }
    }

    // 구간 [L, R]에 V를 더함
    public void updateRange(int L, int R, long V) {
        // BIT1 업데이트 (차이 배열)
        add(bit1, L, V);
        add(bit1, R + 1, -V);

        // BIT2 업데이트 (차이 배열 * 인덱스)
        add(bit2, L, V * L);
        add(bit2, R + 1, -V * (R + 1));
    }

    // 표준 BIT 누적 합 쿼리 헬퍼 메서드
    private long query(long[] bit, int i) {
        long sum = 0;
        while (i > 0) {
            sum += bit[i];
            i -= (i & -i);
        }
        return sum;
    }

    // A[1]부터 A[x]까지의 누적 합 구하기
    public long prefixSum(int x) {
        // 수학 공식: (x + 1) * sum(D[i]) - sum(D[i] * i)
        long sum1 = query(bit1, x);
        long sum2 = query(bit2, x);
        return (x + 1) * sum1 - sum2;
    }

    // 구간 [L, R]의 합 구하기
    public long rangeSum(int L, int R) {
        return prefixSum(R) - prefixSum(L - 1);
    }
}

복잡도 분석

약간의 수학과 두 개의 트리를 사용함에도 불구하고, 성능은 여전히 믿을 수 없을 만큼 최적화되어 있습니다:


초기화 최적화

기본 add() 메서드를 반복적으로 호출하여 펜윅 트리를 구축하면 $O(N \log N)$의 시간이 걸리지만, 이 방법을 사용하면 $O(N)$ (선형 시간) 만에 트리를 완전히 구성할 수 있습니다. 이는 거대한 배열을 초기화해야 하는 코딩 테스트나 고성능 백엔드 환경에서 특히 중요합니다.


1. 단순한 접근법: $O(N \log N)$

대부분의 사람들이 펜윅 트리 초기화에 대해 처음 배우는 표준적인 방법은 0으로 채워진 빈 배열에서 시작하여 입력 배열을 순회하며 각 요소에 대해 add() 함수를 호출하는 것입니다.

add() 함수 내부에는 트리를 타고 올라가며 $O(\log N)$ 단계가 걸리는 while 루프가 있기 때문에, $N$개의 요소에 대해 이 작업을 수행하면 총 $O(N \log N)$의 시간이 소요됩니다.

비유하자면, 매일의 영업 실적을 직속 상사에게만 보고하면 될 것을 굳이 매번 CEO의 사무실까지 걸어 올라가서 보고하는 것과 같습니다.


2. 핵심 아이디어: "직속 상사에게 떠넘기기"

이를 $O(N)$ 시간에 처리하기 위해 상향식(Bottom-up) 접근 방식을 사용합니다.

펜윅 트리의 구조를 생각해 보세요. 모든 노드 $i$는 트리 계층 구조에서 정확히 하나의 직속 부모(Immediate Parent) 에 속합니다. 부모의 구간은 자식의 구간을 완전히 포함합니다.

노드 $i$가 트리를 끝까지 타고 올라가며 모든 조상을 일일이 업데이트하는 대신, 노드 $i$는 자신의 값을 직속 부모에게 단 한 번만 더해주면 됩니다.

우리는 배열을 왼쪽에서 오른쪽($1$부터 $N$까지)으로 순차적으로 처리합니다. 따라서 루프가 어떤 부모 노드에 도달했을 때, 그 부모는 이미 자신보다 작은 모든 자식들로부터 합계를 안전하게 모두 수집해 둔 상태가 됩니다. 그러면 부모는 그렇게 모인 거대한 총합을 다시 자신의 부모에게 한 번에 넘겨주기만 하면 됩니다.


3. 수학적 원리 (직속 부모 찾기)

펜윅 트리에서 노드 $i$의 직속 부모(자신의 값을 필요로 하는 바로 위 노드)는 단순히 $i$에 자신의 최하위 비트(LSB)를 더한 값입니다.

$$parent = i + (i \ & \ -i)$$


4. 알고리즘 (단계별 설명)

  1. 배열 복사: 1-based 인덱스를 사용하는 tree 배열을 새로 만들고, 입력된 원본 값들을 그 자리에 그대로 복사해 넣습니다.
  2. 정방향 순회: $i$를 $1$부터 $N$까지 증가시키며 루프를 돕니다.
  3. 부모 찾기: $parent = i + (i & -i)$를 계산하여 직속 부모를 찾습니다.
  4. 값 전달(Propagate): 계산된 $parent$가 배열의 유효한 크기 내에 있다면($\le N$), tree[i]에 누적된 값을 tree[parent]에 더해줍니다.

5. 구현 코드 (Java)

고도로 최적화된 $O(N)$ 생성자 구현입니다.

public class FenwickTreeLinear {
    private int n;
    private long[] tree;

    // O(N) 초기화 생성자
    public FenwickTreeLinear(long[] values) {
        this.n = values.length;
        this.tree = new long[n + 1];

        // 1. 1-based 인덱스 트리에 원본 값들을 그대로 복사합니다.
        for (int i = 0; i < n; i++) {
            tree[i + 1] = values[i];
        }

        // 2. 누적된 값을 직속 부모에게 전달(Propagate)합니다.
        for (int i = 1; i <= n; i++) {
            int parent = i + (i & -i); // 직속 부모 계산
            
            // 부모가 트리의 범위 내에 있다면, 현재 노드의 총합을 부모에게 더해줍니다.
            if (parent <= n) {
                tree[parent] += tree[i];
            }
        }
    }

    // 표준 점 업데이트 (단일 요소 변경 시 사용)
    public void add(int i, long delta) {
        while (i <= n) {
            tree[i] += delta;
            i += (i & -i);
        }
    }

    // 표준 누적 합 쿼리
    public long query(int i) {
        long sum = 0;
        while (i > 0) {
            sum += tree[i];
            i -= (i & -i);
        }
        return sum;
    }
}

6. 복잡도 분석

지표 복잡도 설명
시간 복잡도 $O(N)$ 배열을 정확히 두 번 순회합니다(복사할 때 한 번, 값을 전달할 때 한 번). 루프 내부에서는 $O(1)$의 덧셈과 비트 연산만 수행합니다.
공간 복잡도 $O(N)$ 트리 배열을 위해 $N+1$ 크기의 메모리가 필요합니다.

이진 리프팅

이 개념을 이해하기 위해, 먼저 우리가 해결하려는 정확한 문제를 정의해 봅시다.

문제: 빈도수 배열 (Frequency Array) 펜윅 트리에 숫자 자체가 아니라 숫자의 빈도수(개수) 를 저장한다고 상상해 보세요.


1. 왜 이진 탐색(Binary Search)을 쓰지 않을까요? ($O(\log^2 N)$)

$k$번째로 작은 원소를 찾는 가장 일반적인 방법은 이진 탐색과 펜윅 트리의 query() 함수를 결합하는 것입니다.

우리는 이것보다 더 잘할 수 있습니다. 이진 리프팅(Binary Lifting) 을 사용하면 정확히 $O(\log N)$ 시간 만에 찾을 수 있습니다.


2. 핵심 개념: 이진 리프팅 (Binary Lifting)

이진 리프팅이란 최상위 비트(MSB, Most Significant Bit)부터 최하위 비트(LSB, Least Significant Bit)까지 내려가면서 정답을 비트 단위로 하나씩 조립해 나가는(Build) 것을 의미합니다.

펜윅 트리의 마법 같은 특성: 펜윅 트리의 구조를 기억하시나요? tree[idx] 노드는 자신의 LSB와 크기가 같은 특정 덩어리(Chunk)의 합을 저장합니다. 배열에서 2의 거듭제곱을 감소시키며(예: +16, +8, +4, +2, +1) 건너뛸 때, tree 배열은 그 점프에 필요한 정확한 덩어리의 합을 이미 가지고 있습니다! 즉, query() 함수를 다시 호출할 필요가 전혀 없습니다.


3. 알고리즘 (단계별 설명)

우리는 누적 합이 $k$보다 엄격하게 작은(strictly less than $k$) 가장 큰 인덱스를 찾고자 합니다. 이 인덱스를 pos라고 부릅시다. 이 값을 찾고 나면, $k$번째로 작은 요소는 단순히 pos + 1이 됩니다.

  1. pos = 0current_sum = 0으로 초기화합니다.
  2. 트리의 최대 용량($N$)보다 작거나 같은 2의 거듭제곱 중 가장 큰 값을 찾습니다. 이를 step이라고 부릅시다.
  3. step > 0인 동안 루프를 돕니다:
    • next_pos = pos + step을 계산합니다.
    • 검사: next_pos가 트리의 범위 내에 있고, 동시에 current_sum + tree[next_pos] < k인가요?
    • 만약 YES라면: 이 전체 덩어리를 더하더라도 여전히 $k$번째 요소에 도달하지 못했다는 뜻입니다. 이 점프를 안전하게 "확정(Commit)"할 수 있습니다.
      • pos = next_pos
      • current_sum += tree[pos]
    • 만약 NO라면: $k$번째 요소가 이 덩어리 안에 있거나, 배열 범위를 벗어났다는 뜻입니다. 점프를 뛰지 않고 그냥 넘어갑니다.
    • 보폭을 반으로 줄입니다: step /= 2.
  4. 정답은 pos + 1입니다.

4. 트레이스 테이블 (시각화)

크기가 16인 펜윅 트리를 가정해 봅시다. 10번째로 작은 원소($k = 10$)를 찾으려고 합니다. 배열에 다음과 같은 빈도수가 있고, BIT가 이미 구축되어 있다고 가정해 보겠습니다:

인덱스 1 2 3 4 5 6 7 8 9 10 11 12
빈도수 2 0 1 3 1 2 0 1 2 4 0 1
누적 합 2 2 3 6 7 9 9 10 12 16 16 17

참고: 10번째 요소는 인덱스 8입니다 (누적 합이 인덱스 8에서 정확히 10에 도달하기 때문입니다).

이진 리프팅 알고리즘을 추적해 봅시다: MSB step = 8 (16 이하의 가장 큰 2의 거듭제곱은 16이므로 16부터 검사하지만 실패하고 8로 넘어왔다고 가정합니다). pos = 0, sum = 0.

Step (2의 거듭제곱) 다음 위치 (pos + step) tree[Next Pos]의 값 검사: sum + tree < 10? 동작 pos sum
8 $0 + 8 = 8$ tree[8]은 10 $0 + 10 < 10$ (거짓) 건너뜀 0 0
4 $0 + 4 = 4$ tree[4]는 6 $0 + 6 < 10$ (참) 점프! 4 6
2 $4 + 2 = 6$ tree[6]은 3 $6 + 3 < 10$ (참) 점프! 6 9
1 $6 + 1 = 7$ tree[7]은 0 $9 + 0 < 10$ (참) 점프! 7 9

루프가 종료됩니다. pos = 7. 정답 = pos + 1 = 8.

우리가 순식간에 4로, 그다음 6으로, 그리고 7로 점프한 것을 주목하세요. 우리는 정답 7의 이진수 표현(0111)을 단계별로 조립한 것입니다!


5. 구현 코드 (Java)

깔끔하게 최적화된 Java 구현체입니다.

public class FenwickTreeBinaryLifting {
    private int n;
    private int[] tree;
    private int msb;

    public FenwickTreeBinaryLifting(int size) {
        this.n = size;
        this.tree = new int[n + 1];
        
        // n보다 작거나 같은 2의 거듭제곱 중 가장 큰 값을 찾습니다.
        // Integer.highestOneBit()는 Java에서 제공하는 매우 빠른 비트 연산입니다.
        this.msb = Integer.highestOneBit(n); 
    }

    public void add(int i, int delta) {
        while (i <= n) {
            tree[i] += delta;
            i += (i & -i);
        }
    }

    // O(log N) 시간에 k번째로 작은 원소 찾기
    public int findKthSmallest(int k) {
        int pos = 0;
        int currentSum = 0;

        // MSB부터 1까지 2의 거듭제곱을 반씩 줄여가며 반복합니다.
        for (int step = msb; step > 0; step /= 2) {
            int nextPos = pos + step;

            // 배열 경계를 넘지 않으면서(<= n),
            // 누적 합이 k에 도달하지 않았는지(< k) 확인합니다.
            if (nextPos <= n && currentSum + tree[nextPos] < k) {
                pos = nextPos;
                currentSum += tree[nextPos];
            }
        }

        // pos는 sum < k를 만족하는 가장 큰 인덱스입니다.
        // 따라서 pos + 1은 sum >= k를 만족하는 첫 번째 인덱스(정답)가 됩니다.
        return pos + 1;
    }
}

6. 복잡도 분석

지표 복잡도 설명
시간 복잡도 $O(\log N)$ 루프는 정확히 $\log N$번 실행됩니다(각 비트당 한 번). 루프 내부의 모든 연산은 $O(1)$입니다.
공간 복잡도 $O(N)$ 표준 펜윅 트리의 배열 크기와 동일합니다.

references